Skip to content

fix(task-graph): legacy unbranded {$ref} read-side rewrap + binaryBackpressure default no-op#559

Closed
sroussey wants to merge 3 commits into
claude/beautiful-mayer-b0do4nfrom
claude/pr557-followup-6k5pug
Closed

fix(task-graph): legacy unbranded {$ref} read-side rewrap + binaryBackpressure default no-op#559
sroussey wants to merge 3 commits into
claude/beautiful-mayer-b0do4nfrom
claude/pr557-followup-6k5pug

Conversation

@sroussey

Copy link
Copy Markdown
Collaborator

Summary

Stacks two follow-ups on top of PR #557 so they merge together.

H-1 — Read-side rewrap of legacy unbranded {$ref} cache rows

Pre-brand cache writes landed as { $ref, size?, mime? } without the literal kind discriminator that #557 introduced. Subsequent isCacheRef probes then treat those rows as ordinary objects and skip threshold-based rehydration. We upgrade them in place — but only at schema-declared binary streaming ports — by reusing the same getStreamingPorts(...).filter(p => p.mode === "binary") set hydrateRefsBelowThreshold uses. Non-binary fields that legitimately carry a {$ref: string} shape (JSON-Schema refs, user metadata) are NEVER auto-promoted, so the shape-only collision risk #557 closes stays closed.

Why option (b) over a __cv bump: a __cv bump throws away every pre-brand cached binary write. Read-side rewrap upgrades those rows transparently.

The new isLegacyUnbrandedCacheRefShape helper is a strict "needs upgrade" predicate; it rejects already-branded refs and shapes whose size/mime hints have the wrong type. CacheCoordinator.lookup upgrades in place on cache hit, and hydrateRefsBelowThreshold accepts both branded refs and the pre-brand shape as defence in depth on the streaming finish-event path that bypasses lookup. RunPrivateCacheRepo.getOutput deliberately does NOT re-wrap — that would have to walk arbitrary nested fields without the schema in scope.

H-2 — binaryBackpressure default no-op

IExecuteContext.binaryBackpressure's JSDoc already promised a "no-op default when the runtime does not install a real backpressure source." Only StreamProcessor was installing the router-aware version, so non-streaming execute() paths handed tasks an IExecuteContext whose field was undefined — calling it threw TypeError. Install a module-private shared no-op in TaskRunner.executeTask and WhileTaskRunner.executeTask so the unconditional-call pattern actually costs nothing per call.

executePreview's IExecutePreviewContext is Pick<IExecuteContext, "own"> and intentionally has no binaryBackpressure slot — the preview path needs no change.

Test coverage

  • CacheRef.legacyShape.test.ts — Case A (threshold 0, ref upgraded); Case B (rehydratable via getOutputByRef); Case C — the load-bearing scope guard: a non-binary port whose cached value is {$ref: "#/$defs/X"} (a legitimate JSON-Schema ref) MUST NOT be touched, and getOutputByRef MUST NEVER be invoked. If Case C breaks, the fix is unsafe to ship.
  • TaskRunner.binaryBackpressure.test.tsexecute() can await ctx.binaryBackpressure() without throwing; repeated calls stay cheap; runPreview() still works.
  • StreamingBackpressure.abortDrain.test.ts — abort-while-parked through StreamProcessor.run settles within ~200ms emitting stream_end or stream_error; sibling routers' waiters wake independently; multiple parked pushes on the same router are all released by a single drain (exercises the prev/res linked-callback chain in BinaryStreamRouter._awaitDrain / push).

Test plan

  • bun scripts/test.ts graph vitest — 839 / 839 tests pass across 86 files
  • bunx tsc -b packages/task-graph — clean
  • All three new test files pass standalone (15 / 15 tests)

This PR stacks on #557 and inherits its base path to main.

https://claude.ai/code/session_01562Z29a2UQDNBVAcJGyUoY


Generated by Claude Code

claude added 3 commits June 10, 2026 08:29
…ary ports only

Pre-brand cache writes landed without the literal kind discriminator, so
subsequent isCacheRef probes treat them as ordinary objects and skip
rehydration / threshold logic. Re-wrap them in place at every declared
binary streaming port — scoped to the same set hydrateRefsBelowThreshold
uses so non-binary fields legitimately carrying a {$ref: string} shape
(JSON-Schema refs, user metadata) are NEVER auto-promoted.

The new isLegacyUnbrandedCacheRefShape helper is the strict "needs
upgrade" predicate; it rejects already-branded refs and shapes whose
size/mime hints have the wrong type. The lookup path upgrades in place
on cache hit, and hydrateRefsBelowThreshold accepts both branded refs
and the pre-brand shape as defence in depth on the streaming
finish-event path that bypasses lookup.

RunPrivateCacheRepo.getOutput deliberately does NOT re-wrap: that would
require walking arbitrary nested fields and would re-introduce the
shape-only collision risk the brand exists to close.
…ecute paths

Tasks may call await ctx.binaryBackpressure() unconditionally; the JSDoc
already promised a no-op default when the runtime does not install a
real backpressure source. Only StreamProcessor was installing the
router-aware version, so non-streaming execute paths handed tasks an
IExecuteContext where the field was undefined - calling it threw
TypeError.

Install a module-private shared no-op in TaskRunner.executeTask and
WhileTaskRunner.executeTask. Sharing the constant avoids per-call
allocation so the unconditional-call pattern actually costs nothing.

executePreview's IExecutePreviewContext is Pick<IExecuteContext, 'own'>
so it intentionally has no binaryBackpressure slot - the preview path
needs no change.
…StreamProcessor

Adds two scenarios over the StreamProcessor + BinaryStreamRouter park/drain
handshake that the existing StreamingBackpressure suite did not exercise:

1. abort-while-parked through StreamProcessor.run - confirms an abort on
   ctx.abortController plus a drain on the router settles the run within
   ~200ms and emits either stream_end or stream_error. Catches future
   regressions where an abort orphans a parked producer push.

2. multi-waiter drain isolation - two concurrently parked pushes on
   sibling routers must wake independently (draining one does not wake
   the other), and multiple parked pushes on the SAME router are all
   released by a single drain. Exercises the prev/res linked-callback
   chain in BinaryStreamRouter._awaitDrain / push.

Copy link
Copy Markdown
Collaborator Author

Closing in favour of PR #565. The fixes in this PR (legacy unbranded {$ref} rewrap, binaryBackpressure no-op default) depend on the binary-streaming framework introduced in PR #557 / #545, which targets claude/stoic-bell-RLJWT and hasn't merged to main yet. These will flow in with that base branch.


Generated by Claude Code

@sroussey sroussey closed this Jun 11, 2026
sroussey added a commit that referenced this pull request Jun 11, 2026
…ift guard (#565)

* fix(ai): export ChunkRetrievalInputSchema + add nightly schema-vs-type drift guard

Cherry-picks the applicable parts of PR #558 that target main:

- Export `ChunkRetrievalInputSchema` (renamed from module-private `inputSchema`)
  so the drift test can reference it; add JSDoc documenting the intentional
  if/then/else tightening over `FromSchema`.
- Add `packages/ai/src/task/__tests__/types.test-d.ts`: vitest typecheck-mode
  assertions that AiChat/ToolCalling prompt types match their `FromSchema`
  resolution and that ChunkRetrieval's discriminated union stays stricter.
- Exclude `*.test-d.ts` from `packages/ai/tsconfig.json` so the per-PR
  typecheck:budget gate stays fast.
- Add `.github/workflows/nightly-typecheck.yml`: nightly + workflow_dispatch
  job that runs the drift test via `--typecheck --typecheck.only`.

PRs #557 and #559 are not cherry-picked: they fix the binary-streaming
framework (CacheRef branding, BinaryStreamRouter backpressure) which lives
on a feature branch not yet merged to main.

https://claude.ai/code/session_013v3PWUAdtJBnWKLbzF8nfe

* chore: update bun.lock

https://claude.ai/code/session_013v3PWUAdtJBnWKLbzF8nfe

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants